# !/usr/bin/env python3
# coding=utf8
"""
剪贴板监视器

打包成可执行文件(不使用 --noconsole, 为了保留控制台):
pyinstaller --onefile ClipboardWatcher.py --add-data "icon.png;." --add-data "default.png;."
"""
import argparse
import colorama
import ctypes
import datetime
import json
import pathlib
import re
import sys
from PySide6.QtWidgets import (QApplication, QMenu, QSystemTrayIcon,
                               QDialog, QMainWindow, QMessageBox,
                               QLabel, QLineEdit, QPushButton,
                               QCheckBox, QComboBox,
                               QSpinBox, QDoubleSpinBox, QGroupBox,
                               QHBoxLayout, QVBoxLayout, QGridLayout)
from PySide6.QtCore import Qt, QPoint, QSize, QTimer
from PySide6.QtGui import QAction, QClipboard, QColor, QIcon, QPixmap


class Config(object):
    """"""

    def __init__(self):
        """"""
        self.config_name: str = 'config.json'
        self.deduplicate: bool = True  # 剪贴板内容去重
        self.display_duration: float = 1.0  # 显示时长, 默认1秒,
        self.font_size: int = 32  # 错误提示的字体大小
        self.font_color: str = Qt.GlobalColor.red.name  # 默认红色
        self.image_name: str = 'default.png'
        self.image_opacity: int = 50  # 图片的透明度
        self.position_offset: QPoint = QPoint(0, 0)
        self.scale_factor: float = 1.0  # 缩放比例
        self.step_value: int = 10  # 步进

    def load(self) -> None:
        """从JSON文件加载设置"""
        config_file: pathlib.Path = self.app_dir().joinpath(self.config_name)
        if config_file.exists():
            with open(config_file, 'r', encoding='utf-8') as fp:
                config = json.load(fp)
                assert isinstance(config, dict)
                self.deduplicate = config.get('deduplicate', True)
                self.display_duration = config.get('display_duration', 1.0)
                self.font_size = config.get('font_size', 32)
                self.font_color = config.get('font_color', Qt.GlobalColor.red.name)
                self.image_name = config.get('image_name', 'default.png')
                self.image_opacity = config.get('image_opacity', 50)
                self.position_offset = QPoint(*config.get('position_offset', [0, 0]))
                self.scale_factor = config.get('scale_factor', 1.0)
                self.step_value = config.get('step_value', 10)

    def save(self) -> None:
        """保存设置到JSON文件"""
        config = {
            'deduplicate': self.deduplicate,
            'display_duration': self.display_duration,
            'font_size': self.font_size,
            'font_color': self.font_color,
            'image_name': self.image_name,
            'image_opacity': self.image_opacity,
            'position_offset': [self.position_offset.x(), self.position_offset.y()],
            'scale_factor': self.scale_factor,
            'step_value': self.step_value,
        }
        config_file: pathlib.Path = self.app_dir().joinpath(self.config_name)
        with open(config_file, 'w', encoding='utf-8') as fp:
            json.dump(config, fp, ensure_ascii=False, indent=4, sort_keys=True)

    @classmethod
    def app_dir(cls) -> pathlib.Path:
        """exe 或 py 所在的目录"""
        if getattr(sys, 'frozen', False):
            return pathlib.Path(sys.executable).parent
        else:
            return pathlib.Path(__file__).parent

    @classmethod
    def res_dir(cls) -> pathlib.Path:
        """资源文件所在的目录"""
        if getattr(sys, 'frozen', False):  # 打包环境,
            try:  # 临时解压目录(资源文件的所在)
                return pathlib.Path(sys._MEIPASS)  # 通常在 %Temp% 里面,
            except AttributeError:  # 单目录模式时, 使用 exe 所在目录,
                return pathlib.Path(sys.executable).parent
        else:  # 开发环境,
            return pathlib.Path(__file__).parent


class ImageToast(QLabel):
    """一个无焦点、有透明度、鼠标穿透、自动消失的图片提示窗口"""

    def __init__(self, parent=None):
        """"""
        super().__init__(parent)

        # 设置窗口标志
        flags: Qt.WindowType = (Qt.Tool |
                                Qt.FramelessWindowHint |  # 无边框
                                Qt.WindowStaysOnTopHint |  # 窗口置顶
                                Qt.WindowDoesNotAcceptFocus |  # 不接受焦点
                                Qt.WindowTransparentForInput)  # 窗口对输入透明
        self.setWindowFlags(flags)
        self.setAttribute(Qt.WA_TranslucentBackground)  # 透明背景
        self.setAttribute(Qt.WA_ShowWithoutActivating)  # 显示但不激活它
        self.setAlignment(Qt.AlignCenter)  # 水平居中+垂直居中

        # 设置定时器
        self.timer: QTimer = QTimer(self)
        self.timer.timeout.connect(self.hide)

    def update_font_style(self, font_size: int, font_color: str) -> None:
        """更新字体样式"""
        font = self.font()
        font.setPointSize(font_size)
        self.setFont(font)

        font_color: str = self.parse_font_color(font_color)

        style_sheet: str = f"""
            background-color: rgba(50, 50, 50, 200);
            color: {font_color};
            border-radius: 5px;
            padding: 10px;
        """
        self.setStyleSheet(style_sheet)

    def update_image(self, image_name: str, scale_factor: float) -> None:
        """"""
        image_path: pathlib.Path = Config.app_dir().joinpath(image_name)
        if not image_path.exists():
            image_path = Config.res_dir().joinpath('default.png')

        if image_path.exists():
            pixmap = QPixmap(image_path)
            if not pixmap.isNull():
                if scale_factor != 1.0:
                    new_size: QSize = pixmap.size() * scale_factor
                    pixmap = pixmap.scaled(
                        new_size,
                        Qt.KeepAspectRatio,  # 保持宽高比
                        Qt.SmoothTransformation  # 平滑缩放
                    )
                self.setPixmap(pixmap)
                # 设置固定大小以确保窗口与图片尺寸完全匹配(需要额外 + 20 否则图片右侧/下侧可能被裁剪)
                self.setFixedSize(QSize(pixmap.size().width() + 20, pixmap.size().height() + 20))
            else:
                self.setMinimumSize(0, 0)
                self.setMaximumSize(0xFFFFFF, 0xFFFFFF)
                self.setText(f"图片文件加载失败: {pathlib.Path(image_name).name}")
                self.adjustSize()
        else:
            self.setMinimumSize(0, 0)
            self.setMaximumSize(0xFFFFFF, 0xFFFFFF)
            self.setText(f"图片文件未找到: {pathlib.Path(image_name).name}")
            self.adjustSize()

    def show_toast(self, config: Config) -> None:
        """显示图片提示"""
        # 停止之前的定时器
        self.timer.stop()
        # 更新图片
        self.update_image(config.image_name, config.scale_factor)
        # 设置透明度
        self.setWindowOpacity(1 - max(0, min(100, config.image_opacity)) / 100.0)
        # 设置位置
        self.move(self.calculate_position(config.position_offset))
        # 更新字体设置
        self.update_font_style(config.font_size, config.font_color)
        # 设置显示时间并启动定时器(秒转毫秒)
        self.timer.setInterval(int(config.display_duration * 1000))
        self.timer.start()
        # 显示窗口
        self.show()

    @classmethod
    def parse_font_color(cls, font_color: str) -> str:
        """解析字体颜色到 #RRGGBB 格式"""
        color: Qt.GlobalColor | None = Qt.GlobalColor.__members__.get(font_color)
        if color:
            return QColor(color).name()

        if re.match(r'^#[0-9A-Fa-f]{6}$', font_color):
            return font_color

        return QColor(Qt.GlobalColor.red).name()

    def calculate_position(self, position_offset: QPoint) -> QPoint:
        """计算位置(屏幕中心+偏移)"""
        screen_geo = QApplication.primaryScreen().availableGeometry()
        center_x = (screen_geo.width() - self.width()) // 2
        center_y = (screen_geo.height() - self.height()) // 2
        position: QPoint = QPoint(center_x + position_offset.x(), center_y + position_offset.y())
        return position


class ConfigDialog(QDialog):
    """设置对话框"""

    def __init__(self, parent=None):
        """"""
        super().__init__(parent)
        self.parent = parent
        self.setWindowTitle("设置")
        self.setModal(True)
        self.setup_ui()
        self.load_config()

    def setup_ui(self) -> None:
        """"""
        layout = QVBoxLayout()

        label = QLabel("如果图片文件过大，请用 <a href='https://tinypng.com/'>TinyPNG</a> 压缩它")
        label.setTextFormat(Qt.TextFormat.RichText)
        label.setOpenExternalLinks(True)
        label.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
        layout.addWidget(label)

        # 内容去重
        self.deduplicate_check = QCheckBox("剪贴板内容去重（内容改变了，才会提示）")
        layout.addWidget(self.deduplicate_check)

        # 图片设置组
        image_group = QGroupBox("图片设置")
        image_layout = QGridLayout()

        row: int = 0
        image_layout.addWidget(QLabel("图片文件名:"), row, 0)
        self.image_name_edit = QLineEdit()
        image_layout.addWidget(self.image_name_edit, row, 1)

        row += 1
        image_layout.addWidget(QLabel("图片缩放比例:"), row, 0)
        self.scale_factor_spin = QDoubleSpinBox()
        self.scale_factor_spin.setRange(0.1, 10.0)
        self.scale_factor_spin.setSingleStep(0.1)
        self.scale_factor_spin.setDecimals(1)
        image_layout.addWidget(self.scale_factor_spin, row, 1)

        row += 1
        image_layout.addWidget(QLabel("透明度 (0-100):"), row, 0)
        self.image_opacity_spin = QSpinBox()
        self.image_opacity_spin.setRange(0, 100)
        self.image_opacity_spin.setSuffix("%")
        image_layout.addWidget(self.image_opacity_spin, row, 1)

        row += 1
        image_layout.addWidget(QLabel("显示时间 (秒):"), row, 0)
        self.display_duration_spin = QDoubleSpinBox()
        self.display_duration_spin.setRange(0.1, 10.0)
        self.display_duration_spin.setSuffix("秒")
        self.display_duration_spin.setSingleStep(0.1)
        self.display_duration_spin.setDecimals(1)
        image_layout.addWidget(self.display_duration_spin, row, 1)

        image_group.setLayout(image_layout)
        layout.addWidget(image_group)

        # 字体设置组
        font_group = QGroupBox("错误提示字体设置")
        font_layout = QGridLayout()

        font_layout.addWidget(QLabel("字体大小:"), 0, 0)
        self.font_size_spin = QSpinBox()
        self.font_size_spin.setRange(8, 48)
        self.font_size_spin.setValue(16)
        font_layout.addWidget(self.font_size_spin, 0, 1)

        font_layout.addWidget(QLabel("字体颜色(#RRGGBB):"), 1, 0)
        self.font_color_combo = QComboBox()
        self.font_color_combo.setEditable(True)
        self.font_color_combo.addItems(sorted(Qt.GlobalColor.__members__.keys()))
        font_layout.addWidget(self.font_color_combo, 1, 1)
        font_group.setLayout(font_layout)
        layout.addWidget(font_group)

        # 位置设置组
        pos_group = QGroupBox("位置调整")
        pos_layout = QVBoxLayout()

        # 步进设置
        step_layout = QHBoxLayout()
        step_layout.addWidget(QLabel("步进值:"))
        self.step_value_spin = QSpinBox()
        self.step_value_spin.setRange(1, 100)
        self.step_value_spin.setValue(10)
        step_layout.addWidget(self.step_value_spin)
        step_layout.addStretch()
        pos_layout.addLayout(step_layout)

        # 方向按钮
        btn_layout = QGridLayout()
        self.up_btn = QPushButton("上")
        self.down_btn = QPushButton("下")
        self.left_btn = QPushButton("左")
        self.right_btn = QPushButton("右")
        self.center_btn = QPushButton("中")

        btn_layout.addWidget(self.center_btn, 1, 1)
        btn_layout.addWidget(self.up_btn, 0, 1)
        btn_layout.addWidget(self.left_btn, 1, 0)
        btn_layout.addWidget(self.right_btn, 1, 2)
        btn_layout.addWidget(self.down_btn, 2, 1)

        pos_layout.addLayout(btn_layout)
        pos_group.setLayout(pos_layout)
        layout.addWidget(pos_group)

        # 按钮组
        btn_group = QHBoxLayout()
        self.test_btn = QPushButton("测试")
        self.apply_btn = QPushButton("应用")
        self.cancel_btn = QPushButton("取消")

        btn_group.addWidget(self.test_btn)
        btn_group.addStretch()
        btn_group.addWidget(self.apply_btn)
        btn_group.addWidget(self.cancel_btn)

        layout.addLayout(btn_group)

        self.setLayout(layout)

        # 连接信号
        self.up_btn.clicked.connect(lambda: self.move_position(0, -1, True))
        self.down_btn.clicked.connect(lambda: self.move_position(0, 1, True))
        self.left_btn.clicked.connect(lambda: self.move_position(-1, 0, True))
        self.right_btn.clicked.connect(lambda: self.move_position(1, 0, True))
        self.center_btn.clicked.connect(lambda: self.move_position(0, 0, False))

        self.test_btn.clicked.connect(self.test_config)
        self.apply_btn.clicked.connect(self.apply_config)
        self.cancel_btn.clicked.connect(self.reject)

        # 用于记录当前位置偏移
        self.position_offset: QPoint = QPoint(0, 0)

    def move_position(self, dx: int, dy: int, is_delta: bool) -> None:
        """移动位置"""
        step: int = self.step_value_spin.value()
        if is_delta:
            self.position_offset += QPoint(dx * step, dy * step)
        else:
            self.position_offset = QPoint(dx * step, dy * step)
        self.test_config()

    def load_config(self) -> None:
        """加载当前设置"""
        assert isinstance(self.parent, ClipboardWatcher)

        self.deduplicate_check.setChecked(self.parent.config.deduplicate)
        self.display_duration_spin.setValue(self.parent.config.display_duration)
        self.font_color_combo.setCurrentText(self.parent.config.font_color)
        self.font_size_spin.setValue(self.parent.config.font_size)
        self.image_name_edit.setText(self.parent.config.image_name)
        self.image_opacity_spin.setValue(self.parent.config.image_opacity)
        self.position_offset = QPoint(self.parent.config.position_offset)
        self.scale_factor_spin.setValue(self.parent.config.scale_factor)
        self.step_value_spin.setValue(self.parent.config.step_value)

    def test_config(self) -> None:
        """测试当前设置"""
        assert isinstance(self.parent, ClipboardWatcher)

        config: Config = Config()
        config.deduplicate = self.deduplicate_check.isChecked()
        config.display_duration = self.display_duration_spin.value()
        config.font_color = self.font_color_combo.currentText().strip()
        config.font_size = self.font_size_spin.value()
        config.image_name = self.image_name_edit.text().strip()
        config.image_opacity = self.image_opacity_spin.value()
        config.position_offset = self.position_offset
        config.scale_factor = self.scale_factor_spin.value()
        config.step_value = self.step_value_spin.value()

        self.parent.toast.show_toast(config)

    def apply_config(self) -> None:
        """应用设置"""
        assert isinstance(self.parent, ClipboardWatcher)

        self.parent.config.deduplicate = self.deduplicate_check.isChecked()
        self.parent.config.display_duration = self.display_duration_spin.value()
        self.parent.config.font_color = self.font_color_combo.currentText().strip()
        self.parent.config.font_size = self.font_size_spin.value()
        self.parent.config.image_name = self.image_name_edit.text().strip()
        self.parent.config.image_opacity = self.image_opacity_spin.value()
        self.parent.config.position_offset = self.position_offset
        self.parent.config.scale_factor = self.scale_factor_spin.value()
        self.parent.config.step_value = self.step_value_spin.value()
        self.parent.config.save()

        self.accept()


class ClipboardWatcher(QMainWindow):
    """主窗口, 带有系统托盘功能"""

    def __init__(self):
        """"""
        super().__init__()
        self.hide()

        self.output_level: int = 0  # 控制台的输出等级,

        self.config: Config = Config()
        # 加载保存的设置
        self.config.load()
        # 创建单个 ImageToast 实例
        self.toast: ImageToast = ImageToast(self)
        # 设置窗口实例引用
        self.config_dialog: ConfigDialog | None = None
        # 设置系统托盘
        self.setup_tray()
        # 设置剪贴板监听
        self.clipboard: QClipboard = QApplication.clipboard()
        self.clipboard.dataChanged.connect(self.on_clipboard_changed)
        self.last_clipboard_text: str = self.clipboard.text()

    def setup_tray(self) -> None:
        """设置系统托盘"""
        if not QSystemTrayIcon.isSystemTrayAvailable():
            QMessageBox.critical(None, "系统托盘", "系统不支持托盘功能")
            return

        # 创建托盘图标
        self.tray_icon = QSystemTrayIcon(self)
        icon_path: pathlib.Path = Config.res_dir().joinpath("icon.png")
        icon: QIcon = QIcon(icon_path.as_posix()) if icon_path.exists() else QIcon.fromTheme("emblem-favorite")
        self.tray_icon.setIcon(icon)

        # 创建托盘菜单
        tray_menu = QMenu()

        settings_action = QAction("设置", self)
        settings_action.triggered.connect(self.show_config)

        quit_action = QAction("退出", self)
        quit_action.triggered.connect(self.quit_application)

        tray_menu.addAction(settings_action)
        tray_menu.addAction(quit_action)

        self.tray_icon.setContextMenu(tray_menu)
        self.tray_icon.show()
        self.tray_icon.setToolTip("剪贴板监视器")

    def set_output_level(self, level: int) -> None:
        """"""
        self.output_level = max(0, level)
        if 0 < self.output_level:
            colorama.init()

    def show_config(self) -> None:
        """显示设置对话框"""
        if self.config_dialog is None:
            self.config_dialog = ConfigDialog(self)
            self.config_dialog.show()
        else:
            if not self.config_dialog.isVisible():
                # 如果被 accept/reject 了, 可能有脏数据,
                self.config_dialog.load_config()
                self.config_dialog.show()
            else:
                # 如果设置窗口已经打开, 将其置顶,
                self.config_dialog.raise_()
                self.config_dialog.activateWindow()

    def quit_application(self) -> None:
        """退出应用程序"""
        QApplication.quit()

    def on_clipboard_changed(self) -> None:
        """剪贴板内容变化时的处理"""
        current_text = self.clipboard.text()
        text_changed: bool = current_text != self.last_clipboard_text
        self.last_clipboard_text = current_text
        if self.config.deduplicate:
            if text_changed:
                self.show_image_toast()
        else:
            self.show_image_toast()

        if (self.output_level == 1 and text_changed) or self.output_level == 2:
            print(f'{colorama.Fore.YELLOW}+++ {now()} (len: {len(current_text)}) +++{colorama.Style.RESET_ALL}')
            print(current_text)

    def show_image_toast(self) -> None:
        """显示图片提示"""
        self.toast.show_toast(self.config)


def now() -> str:
    """"""
    return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='剪贴板监视器')
    parser.add_argument('-v', '--version', action='version', version='2025.09.26')
    parser.add_argument('-c', '--console', help='keep console', action='store_true')
    parser.add_argument('-l', '--level', type=int, default=0, choices=[0, 1, 2], help='0: no output, 1: output deduplicated text, 2: output clipboard text')
    parser.add_argument('--resource_dir', help='print resource directory', action='store_true')
    args = parser.parse_args()

    if args.resource_dir:
        print(f'resource_dir: {Config.res_dir()}')

    if getattr(sys, 'frozen', False) and sys.platform.startswith('win') and not args.console:
        # 打包环境 && Windows平台 && 没有显式需求控制台, 就隐藏控制台窗口,
        ctypes.windll.user32.ShowWindow(ctypes.windll.kernel32.GetConsoleWindow(), 0)
        args.level = 0

    app = QApplication(sys.argv)

    # 确保应用程序在窗口关闭时不退出
    app.setQuitOnLastWindowClosed(False)

    watcher = ClipboardWatcher()
    watcher.set_output_level(args.level)

    sys.exit(app.exec())
